web/src/routes/image/[id]/+page.svelte (view raw)
1<script lang="ts">
2 import { onMount } from 'svelte';
3 import { getImage, imageDownloadUrl, thumbnailUrl, type ImageDetail } from '$lib/api';
4
5 let { params } = $props();
6 let id = $derived(parseInt(params.id));
7
8 let img = $state<ImageDetail | null>(null);
9 let loading = $state(true);
10 let error = $state('');
11
12 onMount(async () => {
13 try {
14 img = await getImage(id);
15 } catch (err) {
16 error = err instanceof Error ? err.message : 'Failed to load image';
17 } finally {
18 loading = false;
19 }
20 });
21
22 function formatSize(bytes: number): string {
23 if (bytes < 1024) return bytes + ' B';
24 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
25 return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
26 }
27
28 function formatDate(s: string): string {
29 return new Date(s).toLocaleString();
30 }
31</script>
32
33{#if loading}
34 <p class="state-msg">Loading...</p>
35{:else if error}
36 <p class="state-msg error">{error}</p>
37{:else if img}
38 <div class="page-head">
39 <h1>{img.filename}</h1>
40 <div class="head-line"></div>
41 </div>
42
43 <div class="layout">
44 <div class="preview">
45 {#if img.thumbnail_path}
46 <img src={imageDownloadUrl(img.id)} alt={img.filename} />
47 {:else}
48 <div class="no-thumb">NO THUMBNAIL</div>
49 {/if}
50 </div>
51
52 <div class="meta">
53 <div class="meta-block">
54 <span class="meta-label">ID</span>
55 <span class="meta-value">#{img.id}</span>
56 </div>
57 <div class="meta-block">
58 <span class="meta-label">DIMENSIONS</span>
59 <span class="meta-value">{img.width} × {img.height}</span>
60 </div>
61 <div class="meta-block">
62 <span class="meta-label">FILE SIZE</span>
63 <span class="meta-value">{formatSize(img.file_size)}</span>
64 </div>
65 <div class="meta-block">
66 <span class="meta-label">TYPE</span>
67 <span class="meta-value">{img.mime_type}</span>
68 </div>
69 <div class="meta-block">
70 <span class="meta-label">UPLOADED BY</span>
71 <span class="meta-value">{img.uploader_username}</span>
72 </div>
73 <div class="meta-block">
74 <span class="meta-label">UPLOADED</span>
75 <span class="meta-value">{formatDate(img.created_at)}</span>
76 </div>
77 <div class="meta-block tags-block">
78 <span class="meta-label">TAGS</span>
79 <div class="tag-pills">
80 {#if img.tags_detail.length > 0}
81 {#each img.tags_detail as t (t.tag)}
82 <a href="/browse?tag={encodeURIComponent(t.tag)}" class="tag-pill" title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}">
83 <span class="tag-name">{t.tag}</span>
84 <span class="tag-count">{t.usage_count}</span>
85 </a>
86 {/each}
87 {:else}
88 <span class="none">none</span>
89 {/if}
90 </div>
91 </div>
92
93 <a href={imageDownloadUrl(img.id)} class="btn" download={img.filename}>
94 DOWNLOAD ORIGINAL
95 </a>
96 </div>
97 </div>
98
99 <p class="back"><a href="/browse">← BACK TO BROWSE</a></p>
100{/if}
101
102<style>
103 .state-msg {
104 padding: 2rem 0;
105 font-size: 1.1rem;
106 color: #888;
107 }
108 .error {
109 color: #c62828;
110 }
111
112 .page-head {
113 margin-bottom: 1.5rem;
114 }
115 .page-head h1 {
116 word-break: break-all;
117 }
118 .head-line {
119 height: 3px;
120 background: #c62828;
121 width: 3rem;
122 margin-top: 0.4rem;
123 }
124
125 .layout {
126 display: flex;
127 gap: 2rem;
128 flex-wrap: wrap;
129 }
130 .preview {
131 flex: 1;
132 min-width: 300px;
133 background: #1a1a1a;
134 display: flex;
135 align-items: center;
136 justify-content: center;
137 }
138 .preview img {
139 display: block;
140 width: 100%;
141 height: auto;
142 max-height: 70vh;
143 object-fit: contain;
144 }
145 .no-thumb {
146 padding: 4rem 2rem;
147 color: #555;
148 font-family: 'Oswald', sans-serif;
149 font-weight: 500;
150 font-size: 0.9rem;
151 letter-spacing: 0.12em;
152 }
153
154 .meta {
155 width: 260px;
156 flex-shrink: 0;
157 display: flex;
158 flex-direction: column;
159 gap: 1rem;
160 }
161 .meta-block {
162 display: flex;
163 flex-direction: column;
164 gap: 0.15rem;
165 }
166 .meta-label {
167 font-family: 'Oswald', sans-serif;
168 font-weight: 500;
169 font-size: 0.7rem;
170 letter-spacing: 0.15em;
171 color: #999;
172 }
173 .meta-value {
174 font-family: 'Inter', sans-serif;
175 font-size: 0.9rem;
176 font-weight: 600;
177 color: #1a1a1a;
178 }
179
180 .tags-block {
181 flex: 1;
182 }
183 .tag-pills {
184 display: flex;
185 flex-wrap: wrap;
186 gap: 0.35rem;
187 }
188 .tag-pill {
189 display: inline-flex;
190 align-items: center;
191 gap: 0.3rem;
192 background: #1a1a1a;
193 padding: 0.25rem 0.5rem 0.25rem 0.65rem;
194 font-family: 'Inter', sans-serif;
195 font-size: 0.78rem;
196 font-weight: 500;
197 color: #eee;
198 text-decoration: none;
199 transition: background 0.15s;
200 }
201 .tag-pill:hover {
202 background: #c62828;
203 text-decoration: none;
204 }
205 .tag-name {
206 font-weight: 600;
207 }
208 .tag-count {
209 background: rgba(255, 255, 255, 0.12);
210 padding: 0.05rem 0.35rem;
211 font-size: 0.68rem;
212 font-weight: 600;
213 color: rgba(255, 255, 255, 0.65);
214 min-width: 1.1rem;
215 text-align: center;
216 }
217 .tag-pill:hover .tag-count {
218 background: rgba(0, 0, 0, 0.2);
219 }
220 .none {
221 color: #bbb;
222 font-size: 0.85rem;
223 }
224
225 .btn {
226 display: inline-flex;
227 align-items: center;
228 align-self: flex-start;
229 padding: 0.55rem 1.25rem;
230 background: #c62828;
231 color: #fff;
232 border: none;
233 font-family: 'Oswald', sans-serif;
234 font-weight: 500;
235 font-size: 0.8rem;
236 letter-spacing: 0.1em;
237 text-decoration: none;
238 text-transform: uppercase;
239 cursor: pointer;
240 transition: background 0.15s;
241 margin-top: 0.5rem;
242 }
243 .btn:hover {
244 background: #b71c1c;
245 text-decoration: none;
246 }
247
248 .back {
249 margin-top: 2rem;
250 }
251 .back a {
252 font-family: 'Oswald', sans-serif;
253 font-weight: 500;
254 font-size: 0.85rem;
255 letter-spacing: 0.1em;
256 }
257</style>